Skip to content

fix: Anthropic 与 Chat Completions 转换的思考块问题#4591

Open
clansty wants to merge 3 commits intoQuantumNous:mainfrom
clansty:fix/deepseek-reasoning-replay
Open

fix: Anthropic 与 Chat Completions 转换的思考块问题#4591
clansty wants to merge 3 commits intoQuantumNous:mainfrom
clansty:fix/deepseek-reasoning-replay

Conversation

@clansty
Copy link
Copy Markdown
Contributor

@clansty clansty commented May 2, 2026

对于入口是 Anthropic,出口是 OpenAI Chat Completions 兼容 API 的情况,使用 OpenCode 调用 deepseek v4 pro 或者 kimi k2.6 之类的模型,会出现在第一个工具调用之后提示

The `reasoning_content` in the thinking mode must be passed back to the API.

的情况,有以下两个原因:

  1. new-api 在从 Anthropic 转换到 Chat Completions 的路径上,并没有对传入的思考块进行转换,而是直接丢弃了
  2. OpenCode 在接收到来自 Anthropic 接口的,带有文本内容但是不带签名的思考块的时候,能在界面上显示出来,但是并不会发回给 API

对于第一个问题,我已经实现了思考块的转换。对于第二个问题,我认为签名字段和思考文本内容字段一样,就算是空字符串也不能是 null 值

有关思考块转换,我现在的实现是,在 Chat Completions 接口上,使用 reasoning_opaque 字段存储签名值。Chat Responses 并没有一个标准的思考签名存储字段,但是据我的观察,其他的应用程序有在使用 reasoning_opaque 传输签名。但是对于来回都是同一渠道并且国产模型的场合,似乎也不会设计到思考签名。所以这大概算是一个保守设计

Summary

  • Add reasoning_opaque as an OpenAI-compatible extension field for opaque reasoning metadata.
  • Preserve Anthropic thinking blocks across OpenAI-compatible conversion by mapping thinking to reasoning_content and signature to reasoning_opaque.
  • Restore reasoning_content/reasoning_opaque back to Anthropic thinking blocks before tool-use replay.

Why

DeepSeek thinking mode requires assistant reasoning content to be replayed when a tool call occurs. Without preserving this metadata through Anthropic/OpenAI-compatible conversion, follow-up tool-result requests can fail with a missing reasoning-content error. Anthropic also carries signed thinking via signature, so this keeps the opaque signature separate from visible reasoning text.

Tests

  • go test ./service
  • go test ./relay/channel/deepseek

Notes

  • reasoning_opaque is treated as a compatibility extension for new-api/OpenAI-compatible traffic, not as visible reasoning text.
  • Existing go test ./relay/channel/claude currently has unrelated file-content conversion test failures on this branch.

Summary by CodeRabbit

  • New Features

    • Added optional opaque reasoning field and nullable signature support in messages and stream responses.
    • Converters now preserve and emit "thinking" reasoning content and opaque signatures across formats, and prepend thinking blocks where applicable.
  • Bug Fixes

    • Ensure safe handling of missing relay/channel info and skip empty-only thinking messages.
    • Emit signature-related deltas before tool-use outputs.
  • Tests

    • Added tests covering reasoning preservation, signature ordering, nil relay handling, and empty-thinking cases.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

Walkthrough

Adds nullable opaque reasoning/signature fields to Claude and OpenAI DTOs, updates Claude↔OpenAI conversion to propagate reasoning content and opaque signatures (with defensive nil handling and send-tracking), and adds tests covering request/stream/response conversions and edge cases.

Changes

Opaque reasoning/signature propagation

Layer / File(s) Summary
Data Shape
dto/openai_request.go, dto/openai_response.go, dto/claude.go
Added ReasoningOpaque *string to OpenAI request Message and stream ChatCompletionsStreamResponseChoiceDelta with GetReasoningOpaque() accessors; changed ClaudeMediaMessage.Signature from string to *string.
Conversion Core
service/convert.go
Defensive nil-handling for RelayInfo fields; map Claude thinking media to OpenAI ReasoningContent and ReasoningOpaque; accumulate reasoning content/opaque in info.ClaudeConvertInfo; emit signature_delta before tool_use; set SentThinking/SentSignature flags.
State Container
relay/common/relay_info.go
Added ReasoningContent, ReasoningOpaque, SentThinking, and SentSignature to ClaudeConvertInfo.
Tests / Validation
service/convert_test.go
Added tests validating stream response emits signature before tool-use (including blank signature), request conversion preserves thinking and opaque signatures across tool-use and with nil RelayInfo/ChannelMeta, and response conversion prepends thinking with signature before tool_use.
Minor / Manifest
go.mod
Manifest updates referenced by diffs.

Sequence Diagram(s)

sequenceDiagram
  participant Claude
  participant Converter
  participant OpenAI
  participant RelayInfo
  Claude->>Converter: emits message (may include thinking + signature)
  Converter->>RelayInfo: read/update ClaudeConvertInfo (ReasoningContent/ReasoningOpaque)
  Converter->>OpenAI: sends OpenAI request with ReasoningContent & ReasoningOpaque
  OpenAI->>Converter: streams deltas (content, reasoning content, reasoning opaque)
  Converter->>RelayInfo: accumulate reasoning content/opaque, set SentThinking/SentSignature
  Converter->>Claude: emit thinking content_block, then signature_delta, then tool_use
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • seefs001
  • Calcium-Ion

Poem

🐰 I nudge the bytes where thoughts belong,
A tiny signature hums along,
From Claude's hush to OpenAI's light,
Reasoning carried—soft and bright,
Tests hop through, and all is strong.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title is in Chinese and addresses reasoning/thinking block handling in Anthropic-to-OpenAI conversions, which matches the core objective of preserving reasoning metadata across format conversions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@service/convert_test.go`:
- Around line 88-104: The test
TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage currently only covers a
nil Thinking pointer; update tests to cover the non-nil empty-string pointer
case as well (create a dto.ClaudeMediaMessage with Thinking set to
common.GetPointer("")) to exercise ClaudeToOpenAIRequest through
ParseContent/ToolCalls, or rename the test to indicate it only covers the
nil-Thinking scenario; ensure the added sub-case asserts that
openAIRequest.Messages remains empty and that no tool call/ReasoningContent is
produced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2db6b79-ff64-4e46-95a9-1d92e2f2905c

📥 Commits

Reviewing files that changed from the base of the PR and between dac55f0 and aa046a2.

📒 Files selected for processing (4)
  • dto/openai_request.go
  • dto/openai_response.go
  • service/convert.go
  • service/convert_test.go

Comment thread service/convert_test.go
Comment on lines +88 to +104
func TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage(t *testing.T) {
claudeRequest := dto.ClaudeRequest{
Model: "deepseek-v4-pro",
Messages: []dto.ClaudeMessage{
{
Role: "assistant",
Content: []dto.ClaudeMediaMessage{
{Type: "thinking"},
},
},
},
}

openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, testRelayInfo())
require.NoError(t, err)
require.Empty(t, openAIRequest.Messages)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test name vs test fixture mismatch — {Type: "thinking"} has a nil Thinking pointer, not an empty string.

TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage tests the nil-Thinking case only. A thinking block with an explicit Thinking: common.GetPointer("") (non-nil pointer to empty string) would set ReasoningContent to &"" but the message would still be dropped by the ParseContent()/ToolCalls gate — that path is untested. The current test still validates the primary use case, but the name implies coverage it doesn't provide.

🛡️ Proposed additional sub-case
 func TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage(t *testing.T) {
+	// Case 1: nil Thinking pointer
 	claudeRequest := dto.ClaudeRequest{
 		Model: "deepseek-v4-pro",
 		Messages: []dto.ClaudeMessage{
 			{
 				Role: "assistant",
 				Content: []dto.ClaudeMediaMessage{
 					{Type: "thinking"},
 				},
 			},
 		},
 	}
 
 	openAIRequest, err := ClaudeToOpenAIRequest(claudeRequest, testRelayInfo())
 	require.NoError(t, err)
 	require.Empty(t, openAIRequest.Messages)
+
+	// Case 2: non-nil pointer to empty string
+	emptyStr := ""
+	claudeRequest2 := dto.ClaudeRequest{
+		Model: "deepseek-v4-pro",
+		Messages: []dto.ClaudeMessage{
+			{
+				Role: "assistant",
+				Content: []dto.ClaudeMediaMessage{
+					{Type: "thinking", Thinking: &emptyStr},
+				},
+			},
+		},
+	}
+	openAIRequest2, err2 := ClaudeToOpenAIRequest(claudeRequest2, testRelayInfo())
+	require.NoError(t, err2)
+	require.Empty(t, openAIRequest2.Messages)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/convert_test.go` around lines 88 - 104, The test
TestClaudeToOpenAIRequestSkipsEmptyThinkingOnlyMessage currently only covers a
nil Thinking pointer; update tests to cover the non-nil empty-string pointer
case as well (create a dto.ClaudeMediaMessage with Thinking set to
common.GetPointer("")) to exercise ClaudeToOpenAIRequest through
ParseContent/ToolCalls, or rename the test to indicate it only covers the
nil-Thinking scenario; ensure the added sub-case asserts that
openAIRequest.Messages remains empty and that no tool call/ReasoningContent is
produced.

@clansty clansty marked this pull request as draft May 2, 2026 12:56
@clansty clansty marked this pull request as ready for review May 2, 2026 13:56
@clansty
Copy link
Copy Markdown
Contributor Author

clansty commented May 2, 2026

已测试能解决问题

@clansty clansty changed the title fix: preserve reasoning metadata for tool replay fix: Anthropic 与 Chat Completions 转换的思考块问题 May 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant